/*
 * Copyright 2015-2025 the original author or authors.
 *
 * All rights reserved. This program and the accompanying materials are
 * made available under the terms of the Eclipse Public License v2.0 which
 * accompanies this distribution and is available at
 *
 * https://www.eclipse.org/legal/epl-v20.html
 */

package org.junit.platform.reporting.open.xml;

import static java.util.Objects.requireNonNull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectUniqueId;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_STDERR_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.CAPTURE_STDOUT_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.OUTPUT_DIR_PROPERTY_NAME;
import static org.junit.platform.launcher.LauncherConstants.OUTPUT_DIR_UNIQUE_NUMBER_PLACEHOLDER;
import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request;
import static org.junit.platform.launcher.core.LauncherFactoryForTestingPurposesOnly.createLauncher;
import static org.junit.platform.reporting.open.xml.OpenTestReportGeneratingListener.ENABLED_PROPERTY_NAME;
import static org.junit.platform.reporting.open.xml.OpenTestReportGeneratingListener.GIT_ENABLED_PROPERTY_NAME;
import static org.junit.platform.reporting.open.xml.OpenTestReportGeneratingListener.SOCKET_PROPERTY_NAME;
import static org.junit.platform.reporting.testutil.FileUtils.findPath;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.engine.TestEngine;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.reporting.FileEntry;
import org.junit.platform.engine.reporting.ReportEntry;
import org.junit.platform.engine.support.hierarchical.DemoHierarchicalTestEngine;
import org.junit.platform.tests.process.ProcessResult;
import org.junit.platform.tests.process.ProcessStarter;
import org.opentest4j.reporting.schema.Namespace;
import org.opentest4j.reporting.tooling.core.validator.DefaultValidator;
import org.opentest4j.reporting.tooling.core.validator.ValidationResult;
import org.xmlunit.assertj3.XmlAssert;
import org.xmlunit.placeholder.PlaceholderDifferenceEvaluator;

/**
 * Tests for {@link OpenTestReportGeneratingListener}.
 *
 * @since 1.9
 */
public class OpenTestReportGeneratingListenerTests {

	private static final Map<String, String> NAMESPACE_CONTEXT = Map.of( //
		"core", Namespace.REPORTING_CORE.getUri(), //
		"e", Namespace.REPORTING_EVENTS.getUri(), //
		"git", Namespace.REPORTING_GIT.getUri() //
	);

	private PrintStream originalOut;
	private PrintStream originalErr;

	@BeforeEach
	void wrapSystemPrintStreams() {
		// Work around nesting check in org.junit.platform.launcher.core.StreamInterceptor
		originalOut = System.out;
		System.setOut(new PrintStream(System.out));
		originalErr = System.err;
		System.setErr(new PrintStream(System.err));
	}

	@AfterEach
	void restoreSystemPrintStreams() {
		System.setOut(originalOut);
		System.setErr(originalErr);
	}

	@Test
	void writesValidXmlReport(@TempDir Path tempDirectory) throws Exception {
		var engine = new DemoHierarchicalTestEngine("dummy");
		engine.addTest("failingTest", "display<-->Name 😎", (context, descriptor) -> {
			try {
				var listener = context.request().getEngineExecutionListener();
				listener.reportingEntryPublished(descriptor, ReportEntry.from("key", "value"));
				listener.fileEntryPublished(descriptor, FileEntry.from(
					Files.writeString(tempDirectory.resolve("test.txt"), "Hello, world!"), "text/plain"));
				System.out.println("Hello, stdout!");
				System.err.println("Hello, stderr!");
			}
			catch (Throwable t) {
				throw ExceptionUtils.throwAsUncheckedException(t);
			}
			fail("failure message");
		});

		executeTests(tempDirectory, engine, tempDirectory.resolve("junit-" + OUTPUT_DIR_UNIQUE_NUMBER_PLACEHOLDER));

		var xmlFile = findPath(tempDirectory, "glob:**/open-test-report.xml");
		assertThat(tempDirectory.relativize(xmlFile).toString()) //
				.matches("junit-\\d+[/\\\\]open-test-report.xml");
		assertThat(validate(xmlFile)).isEmpty();

		var expected = """
				<e:events xmlns="https://schemas.opentest4j.org/reporting/core/0.2.0"
				          xmlns:e="https://schemas.opentest4j.org/reporting/events/0.2.0"
				          xmlns:git="https://schemas.opentest4j.org/reporting/git/0.2.0"
				          xmlns:java="https://schemas.opentest4j.org/reporting/java/0.2.0"
				          xmlns:junit="https://schemas.junit.org/open-test-reporting"
				          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
				          xsi:schemaLocation="https://schemas.junit.org/open-test-reporting https://schemas.junit.org/open-test-reporting/junit-1.9.xsd">
				    <infrastructure>
				        <hostName>${xmlunit.ignore}</hostName>
				        <userName>${xmlunit.ignore}</userName>
				        <operatingSystem>${xmlunit.ignore}</operatingSystem>
				        <cpuCores>${xmlunit.ignore}</cpuCores>
				        <java:javaVersion>${xmlunit.ignore}</java:javaVersion>
				        <java:fileEncoding>${xmlunit.ignore}</java:fileEncoding>
				        <java:heapSize max="${xmlunit.isNumber}"/>
				    </infrastructure>
				    <e:started id="1" name="dummy" time="${xmlunit.isDateTime}">
				        <metadata>
				            <junit:uniqueId>[engine:dummy]</junit:uniqueId>
				            <junit:legacyReportingName>dummy</junit:legacyReportingName>
				            <junit:type>CONTAINER</junit:type>
				        </metadata>
				    </e:started>
				    <e:started id="2" name="display&lt;--&gt;Name 😎" parentId="1" time="${xmlunit.isDateTime}">
				        <metadata>
				            <junit:uniqueId>[engine:dummy]/[test:failingTest]</junit:uniqueId>
				            <junit:legacyReportingName>display&lt;--&gt;Name 😎</junit:legacyReportingName>
				            <junit:type>TEST</junit:type>
				        </metadata>
				    </e:started>
				    <e:reported id="2" time="${xmlunit.isDateTime}">
				        <attachments>
				            <data time="${xmlunit.isDateTime}">
				                <entry key="key">value</entry>
				            </data>
				        </attachments>
				    </e:reported>
				    <e:reported id="2" time="${xmlunit.isDateTime}">
				        <attachments>
				            <file time="${xmlunit.isDateTime}" path="${xmlunit.matchesRegex(\\.\\.[/\\\\]test.txt)}" mediaType="text/plain" />
				        </attachments>
				    </e:reported>
				    <e:reported id="2" time="${xmlunit.isDateTime}">
				        <attachments>
				            <output time="${xmlunit.isDateTime}" source="stdout"><![CDATA[Hello, stdout!]]></output>
				            <output time="${xmlunit.isDateTime}" source="stderr"><![CDATA[Hello, stderr!]]></output>
				        </attachments>
				    </e:reported>
				    <e:finished id="2" time="${xmlunit.isDateTime}">
				        <result status="FAILED">
				            <java:throwable assertionError="true" type="org.opentest4j.AssertionFailedError">
				                ${xmlunit.matchesRegex(org\\.opentest4j\\.AssertionFailedError: failure message)}
				            </java:throwable>
				        </result>
				    </e:finished>
				    <e:finished id="1" time="${xmlunit.isDateTime}">
				        <result status="SUCCESSFUL"/>
				    </e:finished>
				</e:events>
				""";

		XmlAssert.assertThat(xmlFile).and(expected) //
				.withDifferenceEvaluator(new PlaceholderDifferenceEvaluator()) //
				.ignoreWhitespace() //
				.areIdentical();
	}

	@ParameterizedTest
	@ValueSource(strings = { "https://github.com/junit-team/junit-framework.git",
			"git@github.com:junit-team/junit-framework.git" })
	void includesGitInfoWhenEnabled(String originUrl, @TempDir Path tempDirectory) throws Exception {

		assumeTrue(tryExecGit(tempDirectory, "--version").exitCode() == 0, "git not installed");
		execGit(tempDirectory, "init", "--initial-branch=my_branch");
		execGit(tempDirectory, "remote", "add", "origin", originUrl);

		Files.writeString(tempDirectory.resolve("README.md"), "Hello, world!");
		execGit(tempDirectory, "add", ".");

		execGit(tempDirectory, "config", "user.name", "Alice");
		execGit(tempDirectory, "config", "user.email", "alice@example.org");
		execGit(tempDirectory, "commit", "--no-gpg-sign", "-m", "Initial commit");

		var engine = new DemoHierarchicalTestEngine("dummy");

		executeTests(tempDirectory, engine, tempDirectory.resolve("junit-reports"));

		var xmlFile = findPath(tempDirectory, "glob:**/open-test-report.xml");
		assertThat(validate(xmlFile)).isEmpty();

		assertThatXml(xmlFile) //
				.doesNotHaveXPath("/e:events/core:infrastructure/git:repository");
		assertThatXml(xmlFile) //
				.doesNotHaveXPath("/e:events/core:infrastructure/git:branch");
		assertThatXml(xmlFile) //
				.doesNotHaveXPath("/e:events/core:infrastructure/git:commit");
		assertThatXml(xmlFile) //
				.doesNotHaveXPath("/e:events/core:infrastructure/git:status");

		executeTests(tempDirectory, engine, tempDirectory.resolve("junit-reports"),
			Map.of(GIT_ENABLED_PROPERTY_NAME, "true"));

		assertThat(validate(xmlFile)).isEmpty();

		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:repository/@originUrl") //
				.isEqualTo(originUrl);

		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:branch") //
				.isEqualTo("my_branch");

		var commitHash = execGit(tempDirectory, "rev-parse", "--verify", "HEAD").stdOut().strip();
		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:commit") //
				.isEqualTo(commitHash);

		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:status/@clean") //
				.isEqualTo(false);

		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:status") //
				.startsWith("?? junit-reports");
	}

	@ParameterizedTest
	@CsvSource(textBlock = """
				https://foo:bar@github.com/junit-team/junit5.git, https://***@github.com/junit-team/junit5.git
				https://token@github.com/junit-team/junit5.git,   https://***@github.com/junit-team/junit5.git
				foo@github.com:junit-team/junit5.git,             ***@github.com:junit-team/junit5.git
				ssh://foo@github.com:junit-team/junit5.git,       ssh://***@github.com:junit-team/junit5.git
				git@github.com:junit-team/junit5.git,             git@github.com:junit-team/junit5.git
				ssh://git@github.com:junit-team/junit5.git,       ssh://git@github.com:junit-team/junit5.git
			""")
	void stripsCredentialsFromOriginUrl(String configuredUrl, String reportedUrl, @TempDir Path tempDirectory)
			throws Exception {

		assumeTrue(tryExecGit(tempDirectory, "--version").exitCode() == 0, "git not installed");
		execGit(tempDirectory, "init", "--initial-branch=my_branch");
		execGit(tempDirectory, "remote", "add", "origin", configuredUrl);

		var engine = new DemoHierarchicalTestEngine("dummy");

		executeTests(tempDirectory, engine, tempDirectory.resolve("junit-reports"),
			Map.of(GIT_ENABLED_PROPERTY_NAME, "true"));

		var xmlFile = findPath(tempDirectory, "glob:**/open-test-report.xml");
		assertThat(validate(xmlFile)).isEmpty();

		assertThatXml(xmlFile) //
				.valueByXPath("/e:events/core:infrastructure/git:repository/@originUrl") //
				.isEqualTo(reportedUrl);
	}

	@Test
	void writesXmlReportToSocket(@TempDir Path tempDirectory) throws Exception {
		var engine = new DemoHierarchicalTestEngine("dummy");
		engine.addTest("test1", "Test 1", (context, descriptor) -> {
			// Simple test
		});

		// Start a server socket to receive the XML
		var builder = new StringBuilder();

		try (var serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { // Use any available port
			int port = serverSocket.getLocalPort();

			// Start a daemon thread to accept the connection and read the XML
			Thread serverThread = new Thread(() -> {
				try (Socket clientSocket = serverSocket.accept();
						var reader = new BufferedReader(
							new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8))) {
					String line;
					while ((line = reader.readLine()) != null) {
						builder.append(line).append("\n");
					}
				}
				catch (Exception e) {
					fail(e);
				}
			});
			serverThread.setDaemon(true);
			serverThread.start();

			// Execute tests with socket configuration
			executeTests(tempDirectory, engine, tempDirectory.resolve("junit-reports"),
				Map.of(SOCKET_PROPERTY_NAME, String.valueOf(port)));

			// Wait for the server to receive the data
			assertThat(serverThread.join(Duration.ofSeconds(10))).isTrue();

			// Verify XML was received
			var expected = """
					<e:events xmlns="https://schemas.opentest4j.org/reporting/core/0.2.0"
					          xmlns:e="https://schemas.opentest4j.org/reporting/events/0.2.0"
					          xmlns:git="https://schemas.opentest4j.org/reporting/git/0.2.0"
					          xmlns:java="https://schemas.opentest4j.org/reporting/java/0.2.0"
					          xmlns:junit="https://schemas.junit.org/open-test-reporting"
					          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
					          xsi:schemaLocation="https://schemas.junit.org/open-test-reporting https://schemas.junit.org/open-test-reporting/junit-1.9.xsd">
					    <infrastructure>
					        <hostName>${xmlunit.ignore}</hostName>
					        <userName>${xmlunit.ignore}</userName>
					        <operatingSystem>${xmlunit.ignore}</operatingSystem>
					        <cpuCores>${xmlunit.ignore}</cpuCores>
					        <java:javaVersion>${xmlunit.ignore}</java:javaVersion>
					        <java:fileEncoding>${xmlunit.ignore}</java:fileEncoding>
					        <java:heapSize max="${xmlunit.isNumber}"/>
					    </infrastructure>
					    <e:started id="1" name="dummy" time="${xmlunit.isDateTime}">
					        <metadata>
					            <junit:uniqueId>[engine:dummy]</junit:uniqueId>
					            <junit:legacyReportingName>dummy</junit:legacyReportingName>
					            <junit:type>CONTAINER</junit:type>
					        </metadata>
					    </e:started>
					    <e:started id="2" name="Test 1" parentId="1" time="${xmlunit.isDateTime}">
					        <metadata>
					            <junit:uniqueId>[engine:dummy]/[test:test1]</junit:uniqueId>
					            <junit:legacyReportingName>Test 1</junit:legacyReportingName>
					            <junit:type>TEST</junit:type>
					        </metadata>
					    </e:started>
					    <e:finished id="2" time="${xmlunit.isDateTime}">
					        <result status="SUCCESSFUL"/>
					    </e:finished>
					    <e:finished id="1" time="${xmlunit.isDateTime}">
					        <result status="SUCCESSFUL"/>
					    </e:finished>
					</e:events>
					""";
			XmlAssert.assertThat(builder.toString()).and(expected) //
					.withDifferenceEvaluator(new PlaceholderDifferenceEvaluator()) //
					.ignoreWhitespace() //
					.areIdentical();
		}
	}

	private static XmlAssert assertThatXml(Path xmlFile) {
		return XmlAssert.assertThat(xmlFile) //
				.withNamespaceContext(NAMESPACE_CONTEXT);
	}

	private static ProcessResult execGit(Path workingDir, String... arguments) throws InterruptedException {
		var result = tryExecGit(workingDir, arguments);
		assertEquals(0, result.exitCode(), "git " + String.join(" ", arguments) + " failed");
		return result;
	}

	private static ProcessResult tryExecGit(Path workingDir, String... arguments) throws InterruptedException {
		System.out.println("$ git " + String.join(" ", arguments));
		return new ProcessStarter() //
				.executable(Path.of("git")) //
				.putEnvironment("GIT_CONFIG_GLOBAL", "/dev/null") // https://git-scm.com/docs/git#Documentation/git.txt-GITCONFIGGLOBAL
				.workingDir(workingDir) //
				.addArguments(arguments) //
				.startAndWait();
	}

	private ValidationResult validate(Path xmlFile) throws URISyntaxException {
		var catalogUri = requireNonNull(getClass().getResource("catalog.xml")).toURI();
		return new DefaultValidator(catalogUri).validate(xmlFile);
	}

	private static void executeTests(Path tempDirectory, TestEngine engine, Path outputDir) {
		executeTests(tempDirectory, engine, outputDir, Map.of());
	}

	private static void executeTests(Path tempDirectory, TestEngine engine, Path outputDir,
			Map<String, String> extraConfigurationParameters) {
		var request = request() //
				.selectors(selectUniqueId(UniqueId.forEngine(engine.getId()))) //
				.enableImplicitConfigurationParameters(false) //
				.configurationParameter(ENABLED_PROPERTY_NAME, String.valueOf(true)) //
				.configurationParameter(CAPTURE_STDOUT_PROPERTY_NAME, String.valueOf(true)) //
				.configurationParameter(CAPTURE_STDERR_PROPERTY_NAME, String.valueOf(true)) //
				.configurationParameter(OUTPUT_DIR_PROPERTY_NAME, outputDir.toString()) //
				.configurationParameters(extraConfigurationParameters) //
				.forExecution() //
				.listeners(new OpenTestReportGeneratingListener(tempDirectory)) //
				.build();
		createLauncher(engine).execute(request);
	}

}
